@intutic/cli 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +868 -30
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/attenuate.d.ts +39 -0
  5. package/dist/commands/attenuate.d.ts.map +1 -0
  6. package/dist/commands/attenuate.js +98 -0
  7. package/dist/commands/attenuate.js.map +1 -0
  8. package/dist/commands/budget.d.ts +80 -0
  9. package/dist/commands/budget.d.ts.map +1 -1
  10. package/dist/commands/budget.js +119 -3
  11. package/dist/commands/budget.js.map +1 -1
  12. package/dist/commands/connect.d.ts +10 -0
  13. package/dist/commands/connect.d.ts.map +1 -1
  14. package/dist/commands/connect.js +358 -48
  15. package/dist/commands/connect.js.map +1 -1
  16. package/dist/commands/credentials.d.ts +31 -0
  17. package/dist/commands/credentials.d.ts.map +1 -0
  18. package/dist/commands/credentials.js +134 -0
  19. package/dist/commands/credentials.js.map +1 -0
  20. package/dist/commands/decision.d.ts +37 -0
  21. package/dist/commands/decision.d.ts.map +1 -0
  22. package/dist/commands/decision.js +56 -0
  23. package/dist/commands/decision.js.map +1 -0
  24. package/dist/commands/doctor.d.ts +38 -1
  25. package/dist/commands/doctor.d.ts.map +1 -1
  26. package/dist/commands/doctor.js +228 -18
  27. package/dist/commands/doctor.js.map +1 -1
  28. package/dist/commands/enforce.d.ts +33 -0
  29. package/dist/commands/enforce.d.ts.map +1 -0
  30. package/dist/commands/enforce.js +150 -0
  31. package/dist/commands/enforce.js.map +1 -0
  32. package/dist/commands/enterprise.d.ts +29 -0
  33. package/dist/commands/enterprise.d.ts.map +1 -0
  34. package/dist/commands/enterprise.js +141 -0
  35. package/dist/commands/enterprise.js.map +1 -0
  36. package/dist/commands/env.d.ts +7 -0
  37. package/dist/commands/env.d.ts.map +1 -0
  38. package/dist/commands/env.js +55 -0
  39. package/dist/commands/env.js.map +1 -0
  40. package/dist/commands/exec.d.ts +38 -2
  41. package/dist/commands/exec.d.ts.map +1 -1
  42. package/dist/commands/exec.js +247 -10
  43. package/dist/commands/exec.js.map +1 -1
  44. package/dist/commands/findings.d.ts +84 -0
  45. package/dist/commands/findings.d.ts.map +1 -0
  46. package/dist/commands/findings.js +329 -0
  47. package/dist/commands/findings.js.map +1 -0
  48. package/dist/commands/gateway.d.ts +62 -0
  49. package/dist/commands/gateway.d.ts.map +1 -0
  50. package/dist/commands/gateway.js +298 -0
  51. package/dist/commands/gateway.js.map +1 -0
  52. package/dist/commands/guardrails.d.ts +81 -0
  53. package/dist/commands/guardrails.d.ts.map +1 -0
  54. package/dist/commands/guardrails.js +735 -0
  55. package/dist/commands/guardrails.js.map +1 -0
  56. package/dist/commands/init.d.ts +8 -0
  57. package/dist/commands/init.d.ts.map +1 -1
  58. package/dist/commands/init.js +14 -2
  59. package/dist/commands/init.js.map +1 -1
  60. package/dist/commands/install-daemon.d.ts +70 -3
  61. package/dist/commands/install-daemon.d.ts.map +1 -1
  62. package/dist/commands/install-daemon.js +482 -47
  63. package/dist/commands/install-daemon.js.map +1 -1
  64. package/dist/commands/integrity.d.ts +205 -0
  65. package/dist/commands/integrity.d.ts.map +1 -0
  66. package/dist/commands/integrity.js +620 -0
  67. package/dist/commands/integrity.js.map +1 -0
  68. package/dist/commands/judge.d.ts +31 -0
  69. package/dist/commands/judge.d.ts.map +1 -0
  70. package/dist/commands/judge.js +118 -0
  71. package/dist/commands/judge.js.map +1 -0
  72. package/dist/commands/org.d.ts +31 -0
  73. package/dist/commands/org.d.ts.map +1 -0
  74. package/dist/commands/org.js +159 -0
  75. package/dist/commands/org.js.map +1 -0
  76. package/dist/commands/policy.d.ts +138 -4
  77. package/dist/commands/policy.d.ts.map +1 -1
  78. package/dist/commands/policy.js +538 -97
  79. package/dist/commands/policy.js.map +1 -1
  80. package/dist/commands/predict-cost.d.ts +45 -0
  81. package/dist/commands/predict-cost.d.ts.map +1 -0
  82. package/dist/commands/predict-cost.js +123 -0
  83. package/dist/commands/predict-cost.js.map +1 -0
  84. package/dist/commands/rollback.d.ts +5 -0
  85. package/dist/commands/rollback.d.ts.map +1 -0
  86. package/dist/commands/rollback.js +142 -0
  87. package/dist/commands/rollback.js.map +1 -0
  88. package/dist/commands/routing.d.ts +62 -0
  89. package/dist/commands/routing.d.ts.map +1 -0
  90. package/dist/commands/routing.js +121 -0
  91. package/dist/commands/routing.js.map +1 -0
  92. package/dist/commands/setup.d.ts +62 -0
  93. package/dist/commands/setup.d.ts.map +1 -0
  94. package/dist/commands/setup.js +238 -0
  95. package/dist/commands/setup.js.map +1 -0
  96. package/dist/commands/skill.d.ts +268 -1
  97. package/dist/commands/skill.d.ts.map +1 -1
  98. package/dist/commands/skill.js +744 -39
  99. package/dist/commands/skill.js.map +1 -1
  100. package/dist/commands/sops.d.ts +95 -0
  101. package/dist/commands/sops.d.ts.map +1 -1
  102. package/dist/commands/sops.js +334 -34
  103. package/dist/commands/sops.js.map +1 -1
  104. package/dist/commands/start.d.ts.map +1 -1
  105. package/dist/commands/start.js +1 -18
  106. package/dist/commands/start.js.map +1 -1
  107. package/dist/commands/status.d.ts.map +1 -1
  108. package/dist/commands/status.js +44 -1
  109. package/dist/commands/status.js.map +1 -1
  110. package/dist/commands/team.d.ts +38 -0
  111. package/dist/commands/team.d.ts.map +1 -0
  112. package/dist/commands/team.js +140 -0
  113. package/dist/commands/team.js.map +1 -0
  114. package/dist/commands/traces.d.ts +8 -1
  115. package/dist/commands/traces.d.ts.map +1 -1
  116. package/dist/commands/traces.js +114 -15
  117. package/dist/commands/traces.js.map +1 -1
  118. package/dist/commands/whoami.d.ts.map +1 -1
  119. package/dist/commands/whoami.js +2 -1
  120. package/dist/commands/whoami.js.map +1 -1
  121. package/dist/config/keychain.d.ts +10 -2
  122. package/dist/config/keychain.d.ts.map +1 -1
  123. package/dist/config/keychain.js +83 -14
  124. package/dist/config/keychain.js.map +1 -1
  125. package/dist/config/paths.d.ts +27 -0
  126. package/dist/config/paths.d.ts.map +1 -1
  127. package/dist/config/paths.js +37 -6
  128. package/dist/config/paths.js.map +1 -1
  129. package/dist/config/store.d.ts.map +1 -1
  130. package/dist/config/store.js +19 -3
  131. package/dist/config/store.js.map +1 -1
  132. package/dist/harness/ag2.d.ts +22 -0
  133. package/dist/harness/ag2.d.ts.map +1 -0
  134. package/dist/harness/ag2.js +34 -0
  135. package/dist/harness/ag2.js.map +1 -0
  136. package/dist/harness/agentcore.d.ts +55 -0
  137. package/dist/harness/agentcore.d.ts.map +1 -0
  138. package/dist/harness/agentcore.js +119 -0
  139. package/dist/harness/agentcore.js.map +1 -0
  140. package/dist/harness/agenticOrchestrator.d.ts +52 -0
  141. package/dist/harness/agenticOrchestrator.d.ts.map +1 -0
  142. package/dist/harness/agenticOrchestrator.js +115 -0
  143. package/dist/harness/agenticOrchestrator.js.map +1 -0
  144. package/dist/harness/aiSdkHarness.d.ts +30 -0
  145. package/dist/harness/aiSdkHarness.d.ts.map +1 -0
  146. package/dist/harness/aiSdkHarness.js +51 -0
  147. package/dist/harness/aiSdkHarness.js.map +1 -0
  148. package/dist/harness/aiSdkWorkflow.d.ts +26 -0
  149. package/dist/harness/aiSdkWorkflow.d.ts.map +1 -0
  150. package/dist/harness/aiSdkWorkflow.js +39 -0
  151. package/dist/harness/aiSdkWorkflow.js.map +1 -0
  152. package/dist/harness/autogen.d.ts +24 -0
  153. package/dist/harness/autogen.d.ts.map +1 -0
  154. package/dist/harness/autogen.js +36 -0
  155. package/dist/harness/autogen.js.map +1 -0
  156. package/dist/harness/base.js.map +1 -1
  157. package/dist/harness/continue.d.ts.map +1 -1
  158. package/dist/harness/continue.js +2 -1
  159. package/dist/harness/continue.js.map +1 -1
  160. package/dist/harness/crewai.d.ts +15 -0
  161. package/dist/harness/crewai.d.ts.map +1 -0
  162. package/dist/harness/crewai.js +26 -0
  163. package/dist/harness/crewai.js.map +1 -0
  164. package/dist/harness/detector.d.ts +1 -1
  165. package/dist/harness/detector.d.ts.map +1 -1
  166. package/dist/harness/detector.js +47 -1
  167. package/dist/harness/detector.js.map +1 -1
  168. package/dist/harness/dsh.d.ts +19 -0
  169. package/dist/harness/dsh.d.ts.map +1 -0
  170. package/dist/harness/dsh.js +93 -0
  171. package/dist/harness/dsh.js.map +1 -0
  172. package/dist/harness/eve.d.ts +40 -0
  173. package/dist/harness/eve.d.ts.map +1 -0
  174. package/dist/harness/eve.js +67 -0
  175. package/dist/harness/eve.js.map +1 -0
  176. package/dist/harness/googleAdk.d.ts +18 -0
  177. package/dist/harness/googleAdk.d.ts.map +1 -0
  178. package/dist/harness/googleAdk.js +29 -0
  179. package/dist/harness/googleAdk.js.map +1 -0
  180. package/dist/harness/goose.d.ts.map +1 -1
  181. package/dist/harness/goose.js +5 -1
  182. package/dist/harness/goose.js.map +1 -1
  183. package/dist/harness/grok.d.ts +16 -0
  184. package/dist/harness/grok.d.ts.map +1 -0
  185. package/dist/harness/grok.js +78 -0
  186. package/dist/harness/grok.js.map +1 -0
  187. package/dist/harness/jsSdkGatedAdapter.d.ts +102 -0
  188. package/dist/harness/jsSdkGatedAdapter.d.ts.map +1 -0
  189. package/dist/harness/jsSdkGatedAdapter.js +139 -0
  190. package/dist/harness/jsSdkGatedAdapter.js.map +1 -0
  191. package/dist/harness/langchain.d.ts +22 -0
  192. package/dist/harness/langchain.d.ts.map +1 -0
  193. package/dist/harness/langchain.js +33 -0
  194. package/dist/harness/langchain.js.map +1 -0
  195. package/dist/harness/langgraph.d.ts +22 -0
  196. package/dist/harness/langgraph.d.ts.map +1 -0
  197. package/dist/harness/langgraph.js +80 -0
  198. package/dist/harness/langgraph.js.map +1 -0
  199. package/dist/harness/mastra.d.ts +21 -0
  200. package/dist/harness/mastra.d.ts.map +1 -0
  201. package/dist/harness/mastra.js +33 -0
  202. package/dist/harness/mastra.js.map +1 -0
  203. package/dist/harness/muse.d.ts +22 -0
  204. package/dist/harness/muse.d.ts.map +1 -0
  205. package/dist/harness/muse.js +92 -0
  206. package/dist/harness/muse.js.map +1 -0
  207. package/dist/harness/n8n.d.ts.map +1 -1
  208. package/dist/harness/n8n.js +36 -11
  209. package/dist/harness/n8n.js.map +1 -1
  210. package/dist/harness/openaiAgents.d.ts +40 -0
  211. package/dist/harness/openaiAgents.d.ts.map +1 -0
  212. package/dist/harness/openaiAgents.js +107 -0
  213. package/dist/harness/openaiAgents.js.map +1 -0
  214. package/dist/harness/opencode.d.ts +24 -0
  215. package/dist/harness/opencode.d.ts.map +1 -0
  216. package/dist/harness/opencode.js +97 -0
  217. package/dist/harness/opencode.js.map +1 -0
  218. package/dist/harness/pydanticAi.d.ts +19 -0
  219. package/dist/harness/pydanticAi.d.ts.map +1 -0
  220. package/dist/harness/pydanticAi.js +30 -0
  221. package/dist/harness/pydanticAi.js.map +1 -0
  222. package/dist/harness/sdkGatedAdapter.d.ts +55 -0
  223. package/dist/harness/sdkGatedAdapter.d.ts.map +1 -0
  224. package/dist/harness/sdkGatedAdapter.js +85 -0
  225. package/dist/harness/sdkGatedAdapter.js.map +1 -0
  226. package/dist/harness/smolagents.d.ts +22 -0
  227. package/dist/harness/smolagents.d.ts.map +1 -0
  228. package/dist/harness/smolagents.js +33 -0
  229. package/dist/harness/smolagents.js.map +1 -0
  230. package/dist/harness/strands.d.ts +22 -0
  231. package/dist/harness/strands.d.ts.map +1 -0
  232. package/dist/harness/strands.js +35 -0
  233. package/dist/harness/strands.js.map +1 -0
  234. package/dist/harness/trueforge.d.ts +25 -0
  235. package/dist/harness/trueforge.d.ts.map +1 -0
  236. package/dist/harness/trueforge.js +40 -0
  237. package/dist/harness/trueforge.js.map +1 -0
  238. package/dist/harness/types.d.ts.map +1 -1
  239. package/dist/harness/types.js +70 -0
  240. package/dist/harness/types.js.map +1 -1
  241. package/dist/harness/vercelAiSdk.d.ts +31 -0
  242. package/dist/harness/vercelAiSdk.d.ts.map +1 -0
  243. package/dist/harness/vercelAiSdk.js +44 -0
  244. package/dist/harness/vercelAiSdk.js.map +1 -0
  245. package/dist/harness/xirp.d.ts +43 -0
  246. package/dist/harness/xirp.d.ts.map +1 -0
  247. package/dist/harness/xirp.js +95 -0
  248. package/dist/harness/xirp.js.map +1 -0
  249. package/dist/lib/api.d.ts +68 -0
  250. package/dist/lib/api.d.ts.map +1 -1
  251. package/dist/lib/api.js +108 -67
  252. package/dist/lib/api.js.map +1 -1
  253. package/dist/lib/authMessages.d.ts +19 -0
  254. package/dist/lib/authMessages.d.ts.map +1 -0
  255. package/dist/lib/authMessages.js +20 -0
  256. package/dist/lib/authMessages.js.map +1 -0
  257. package/dist/lib/binaryChecksum.d.ts +12 -0
  258. package/dist/lib/binaryChecksum.d.ts.map +1 -0
  259. package/dist/lib/binaryChecksum.js +45 -0
  260. package/dist/lib/binaryChecksum.js.map +1 -0
  261. package/dist/lib/binaryOnPath.d.ts +24 -0
  262. package/dist/lib/binaryOnPath.d.ts.map +1 -0
  263. package/dist/lib/binaryOnPath.js +31 -0
  264. package/dist/lib/binaryOnPath.js.map +1 -0
  265. package/dist/lib/caTrust.d.ts +69 -0
  266. package/dist/lib/caTrust.d.ts.map +1 -0
  267. package/dist/lib/caTrust.js +169 -0
  268. package/dist/lib/caTrust.js.map +1 -0
  269. package/dist/lib/ciscoScanner.d.ts +178 -0
  270. package/dist/lib/ciscoScanner.d.ts.map +1 -0
  271. package/dist/lib/ciscoScanner.js +292 -0
  272. package/dist/lib/ciscoScanner.js.map +1 -0
  273. package/dist/lib/decisionsLogRefresh.d.ts +28 -0
  274. package/dist/lib/decisionsLogRefresh.d.ts.map +1 -0
  275. package/dist/lib/decisionsLogRefresh.js +54 -0
  276. package/dist/lib/decisionsLogRefresh.js.map +1 -0
  277. package/dist/lib/deviceReport.d.ts +26 -0
  278. package/dist/lib/deviceReport.d.ts.map +1 -0
  279. package/dist/lib/deviceReport.js +55 -0
  280. package/dist/lib/deviceReport.js.map +1 -0
  281. package/dist/lib/elevation.d.ts +58 -0
  282. package/dist/lib/elevation.d.ts.map +1 -0
  283. package/dist/lib/elevation.js +83 -0
  284. package/dist/lib/elevation.js.map +1 -0
  285. package/dist/lib/enforcementState.d.ts +69 -0
  286. package/dist/lib/enforcementState.d.ts.map +1 -0
  287. package/dist/lib/enforcementState.js +113 -0
  288. package/dist/lib/enforcementState.js.map +1 -0
  289. package/dist/lib/ensureValkey.d.ts.map +1 -1
  290. package/dist/lib/ensureValkey.js +2 -2
  291. package/dist/lib/ensureValkey.js.map +1 -1
  292. package/dist/lib/envInjector.d.ts.map +1 -1
  293. package/dist/lib/envInjector.js +22 -4
  294. package/dist/lib/envInjector.js.map +1 -1
  295. package/dist/lib/gitHooks.d.ts +7 -2
  296. package/dist/lib/gitHooks.d.ts.map +1 -1
  297. package/dist/lib/gitHooks.js +180 -13
  298. package/dist/lib/gitHooks.js.map +1 -1
  299. package/dist/lib/gracefulShutdown.d.ts +37 -0
  300. package/dist/lib/gracefulShutdown.d.ts.map +1 -0
  301. package/dist/lib/gracefulShutdown.js +57 -0
  302. package/dist/lib/gracefulShutdown.js.map +1 -0
  303. package/dist/lib/graphIdentity.d.ts +80 -0
  304. package/dist/lib/graphIdentity.d.ts.map +1 -0
  305. package/dist/lib/graphIdentity.js +120 -0
  306. package/dist/lib/graphIdentity.js.map +1 -0
  307. package/dist/lib/localTraces.d.ts +61 -0
  308. package/dist/lib/localTraces.d.ts.map +1 -0
  309. package/dist/lib/localTraces.js +173 -0
  310. package/dist/lib/localTraces.js.map +1 -0
  311. package/dist/lib/mdmManifest.d.ts +73 -0
  312. package/dist/lib/mdmManifest.d.ts.map +1 -0
  313. package/dist/lib/mdmManifest.js +190 -0
  314. package/dist/lib/mdmManifest.js.map +1 -0
  315. package/dist/lib/onboarding.d.ts.map +1 -1
  316. package/dist/lib/onboarding.js +60 -1
  317. package/dist/lib/onboarding.js.map +1 -1
  318. package/dist/lib/otelPropagation.d.ts +26 -0
  319. package/dist/lib/otelPropagation.d.ts.map +1 -0
  320. package/dist/lib/otelPropagation.js +40 -0
  321. package/dist/lib/otelPropagation.js.map +1 -0
  322. package/dist/lib/policySnapshot.d.ts +92 -0
  323. package/dist/lib/policySnapshot.d.ts.map +1 -0
  324. package/dist/lib/policySnapshot.js +173 -0
  325. package/dist/lib/policySnapshot.js.map +1 -0
  326. package/dist/lib/process.d.ts.map +1 -1
  327. package/dist/lib/process.js +60 -0
  328. package/dist/lib/process.js.map +1 -1
  329. package/dist/lib/providerProbe.d.ts +28 -0
  330. package/dist/lib/providerProbe.d.ts.map +1 -0
  331. package/dist/lib/providerProbe.js +57 -0
  332. package/dist/lib/providerProbe.js.map +1 -0
  333. package/dist/lib/proxyBinary.d.ts +19 -0
  334. package/dist/lib/proxyBinary.d.ts.map +1 -0
  335. package/dist/lib/proxyBinary.js +30 -0
  336. package/dist/lib/proxyBinary.js.map +1 -0
  337. package/dist/lib/sandbox/firecracker.d.ts +89 -0
  338. package/dist/lib/sandbox/firecracker.d.ts.map +1 -0
  339. package/dist/lib/sandbox/firecracker.js +135 -0
  340. package/dist/lib/sandbox/firecracker.js.map +1 -0
  341. package/dist/lib/sandbox/index.d.ts +34 -0
  342. package/dist/lib/sandbox/index.d.ts.map +1 -0
  343. package/dist/lib/sandbox/index.js +54 -0
  344. package/dist/lib/sandbox/index.js.map +1 -0
  345. package/dist/lib/sandbox/oci.d.ts +38 -0
  346. package/dist/lib/sandbox/oci.d.ts.map +1 -0
  347. package/dist/lib/sandbox/oci.js +108 -0
  348. package/dist/lib/sandbox/oci.js.map +1 -0
  349. package/dist/lib/sandbox/types.d.ts +48 -0
  350. package/dist/lib/sandbox/types.d.ts.map +1 -0
  351. package/dist/lib/sandbox/types.js +12 -0
  352. package/dist/lib/sandbox/types.js.map +1 -0
  353. package/dist/lib/sopFrontMatter.d.ts +50 -0
  354. package/dist/lib/sopFrontMatter.d.ts.map +1 -0
  355. package/dist/lib/sopFrontMatter.js +129 -0
  356. package/dist/lib/sopFrontMatter.js.map +1 -0
  357. package/package.json +11 -6
@@ -5,13 +5,263 @@
5
5
  */
6
6
  import { existsSync } from 'node:fs';
7
7
  import * as fs from 'node:fs/promises';
8
- import { join } from 'node:path';
9
- import { spawn } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
9
+ import { dirname, join } from 'node:path';
10
+ import { spawn, execFileSync } from 'node:child_process';
10
11
  import { log } from '../lib/logger.js';
12
+ import { NOT_AUTHENTICATED } from '../lib/authMessages.js';
11
13
  import { loadCredentials, loadConfig } from '../config/store.js';
12
- import { resolveControlPlaneUrl } from '../config/paths.js';
14
+ import { getIntuticDir, resolveControlPlaneUrl } from '../config/paths.js';
13
15
  import { createApiClient } from '../lib/api.js';
16
+ import { scanSkillContent, SKILL_SCAN_PATTERNS, scanScriptContent, SCRIPT_SCAN_PATTERNS, detectScriptLanguage, MAX_SKILL_DIR_DEPTH, MAX_FILES_PER_SKILL, MAX_SCRIPT_SCAN_BYTES, } from '@intutic/shared-types';
17
+ import { ciscoScannerOnPath, runCiscoScan, combineCiscoSarifRuns, } from '../lib/ciscoScanner.js';
14
18
  import pc from 'picocolors';
19
+ /**
20
+ * Cap a skill's content to the same bound the control plane's
21
+ * `SkillFileReportSchema` enforces (`z.string().max(65536)`) before it ever
22
+ * leaves this machine — truncating client-side rather than letting an
23
+ * oversized file 400 the whole report. `content` is judged, then stripped
24
+ * before persistence server-side either way (see that schema's own doc
25
+ * comment); a truncated judge input is a weaker signal, never a stored
26
+ * artifact, so silent truncation here is the same tradeoff
27
+ * `mirrorAdoptionService.ts`'s own `capBytes` makes for judge prompt fields.
28
+ */
29
+ const MAX_SKILL_CONTENT_TRANSPORT_CHARS = 65536;
30
+ export function capContentForTransport(content) {
31
+ return content.length > MAX_SKILL_CONTENT_TRANSPORT_CHARS
32
+ ? content.slice(0, MAX_SKILL_CONTENT_TRANSPORT_CHARS)
33
+ : content;
34
+ }
35
+ /** Where bundled skill directories live, mirroring the conventions
36
+ * `services/sync-daemon/src/agentReporter.ts`'s `collectSkills` already
37
+ * uses for `.agents/skills`, plus Claude Code's own `.claude/skills`. Each
38
+ * entry directly under the root is a skill; `SKILL.md` is its content. */
39
+ const SKILL_DIRECTORY_ROOTS = [
40
+ { dir: '.agents/skills', source: '.agents/skills' },
41
+ { dir: '.claude/skills', source: '.claude/skills' },
42
+ ];
43
+ /**
44
+ * Read, content-scan, and (if `prune` is set and the scan is not clean)
45
+ * auto-prune one real skill file. Extracted from `runSkillAudit` so the
46
+ * discovery/read/scan/report path is testable without the network and
47
+ * credentials setup the full command does.
48
+ *
49
+ * Refusal-not-pass: a read failure returns `scanned: false` with
50
+ * `issuesDetected: 0` — the zero is not a clean bill of health, it is "we
51
+ * never got to look." Callers must treat `scanned: false` as its own status,
52
+ * never as `clean`.
53
+ */
54
+ export async function auditSkillFile(filePath, fullPath, prune,
55
+ /** Suppresses the per-finding `log.warn` narrative. Used by `--sarif`
56
+ * (`runSkillAudit`), whose stdout contract is a single JSON document — a
57
+ * CI tool piping that into a SARIF parser must not see decorated text
58
+ * interleaved with it. Findings are still returned; only the console
59
+ * narration is skipped. Defaults to `false` so every existing caller
60
+ * (including the tests that assert on `log.warn` output indirectly via
61
+ * behaviour) is unaffected. */
62
+ quiet = false,
63
+ /**
64
+ * Phase S5 (TD-357). Attach the file's full content to the returned entry
65
+ * — only ever set by a caller that already confirmed
66
+ * `semanticSkillAnalysisEnabled` for this workspace (`runSkillAudit`
67
+ * below reads it off `client.fetchConfig`'s resolved settings, the same
68
+ * way it already reads `enableLocalSkillAuditDelete`). Defaults to
69
+ * `false` so every existing caller/test is unaffected. */
70
+ attachContent = false) {
71
+ let content;
72
+ try {
73
+ content = await fs.readFile(fullPath, 'utf8');
74
+ }
75
+ catch (err) {
76
+ if (!quiet) {
77
+ log.warn(`[${filePath}] Could not be read (${err instanceof Error ? err.message : String(err)}) — reported as unscanned, not clean.`);
78
+ }
79
+ return { filePath, linesCount: 0, issuesDetected: 0, scanned: false, kind: 'skill_md' };
80
+ }
81
+ const contentLines = content.split('\n');
82
+ const result = scanSkillContent(content);
83
+ const sha256 = createHash('sha256').update(content, 'utf8').digest('hex');
84
+ if (!result.clean && !quiet) {
85
+ for (const finding of result.findings) {
86
+ log.warn(`[${filePath}] ${finding.category} finding (${finding.patternId})${finding.excerpt ? `: ${finding.excerpt}` : ''}`);
87
+ }
88
+ }
89
+ // Auto-prune worthy findings, same opt-in as the legacy rule-file audit:
90
+ // drop any LINE that itself trips a pattern, line-by-line — mirroring the
91
+ // existing per-line re-scan below rather than deleting the whole file,
92
+ // since a skill missing a few flagged lines still functions.
93
+ if (!result.clean && prune) {
94
+ let fileUpdated = false;
95
+ const filteredLines = contentLines.filter((line) => {
96
+ if (!scanSkillContent(line).clean) {
97
+ fileUpdated = true;
98
+ return false;
99
+ }
100
+ return true;
101
+ });
102
+ if (fileUpdated) {
103
+ await fs.writeFile(fullPath, filteredLines.join('\n'), 'utf8');
104
+ if (!quiet)
105
+ log.success(`[${filePath}] Auto-pruned lines flagged by the skill-content scan.`);
106
+ }
107
+ }
108
+ return {
109
+ filePath,
110
+ linesCount: contentLines.length,
111
+ issuesDetected: result.findings.length,
112
+ findings: result.findings,
113
+ sha256,
114
+ scanned: true,
115
+ kind: 'skill_md',
116
+ ...(attachContent ? { content: capContentForTransport(content) } : {}),
117
+ };
118
+ }
119
+ /** Enumerate real `SKILL.md` files under both skill directory roots. A
120
+ * missing root is not an error — most workspaces have neither. Exported so
121
+ * tests can walk a fixture directory tree without going through the full
122
+ * `skill audit`/`skill list` command (credentials, network, etc). */
123
+ export async function discoverSkillFiles(workspaceRoot) {
124
+ const out = [];
125
+ for (const root of SKILL_DIRECTORY_ROOTS) {
126
+ const dir = join(workspaceRoot, root.dir);
127
+ let entries;
128
+ try {
129
+ entries = await fs.readdir(dir, { withFileTypes: true });
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ for (const entry of entries) {
135
+ if (!entry.isDirectory())
136
+ continue;
137
+ const filePath = join(root.dir, entry.name, 'SKILL.md');
138
+ const fullPath = join(workspaceRoot, filePath);
139
+ if (existsSync(fullPath)) {
140
+ out.push({ filePath, fullPath, source: root.source });
141
+ }
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * Bounded, symlink-skipping recursive walk of a skill directory's sibling
148
+ * files — everything alongside `SKILL.md`, the surface TD-356
149
+ * (`docs/TECH_DEBT.md`) named as unenumerated. `SKILL.md` itself is excluded
150
+ * (it is already covered by `discoverSkillFiles`/`auditSkillFile` above);
151
+ * everything else found, at any depth up to the cap, is a candidate for
152
+ * `auditScriptFile`.
153
+ *
154
+ * Bounded by `MAX_SKILL_DIR_DEPTH` and `MAX_FILES_PER_SKILL`
155
+ * (`@intutic/shared-types`) — a skill directory is developer-authored
156
+ * content, not something this command should ever walk unboundedly. Depth 0
157
+ * is the skill directory itself; nested subdirectories increment from
158
+ * there, and the walk stops recursing once depth exceeds the cap.
159
+ *
160
+ * Symlinks are never followed: `Dirent.isSymbolicLink()` is checked during
161
+ * `readdir` and skipped outright, without ever stat-ing or resolving where
162
+ * the link points. A symlink inside a skill directory could point outside
163
+ * that directory — or outside the workspace entirely — which would turn a
164
+ * bounded, skill-scoped walk into an effectively unbounded one, and would
165
+ * let a skill directory "bundle" a script that does not actually live on
166
+ * disk inside it.
167
+ */
168
+ export async function discoverSkillBundledFiles(workspaceRoot,
169
+ /** Path to the skill directory (not `SKILL.md` itself), relative to
170
+ * `workspaceRoot`, e.g. `.agents/skills/my-skill`. */
171
+ skillDirPath) {
172
+ const out = [];
173
+ async function walk(relDir, depth) {
174
+ if (depth > MAX_SKILL_DIR_DEPTH || out.length >= MAX_FILES_PER_SKILL)
175
+ return;
176
+ let entries;
177
+ try {
178
+ entries = await fs.readdir(join(workspaceRoot, relDir), { withFileTypes: true });
179
+ }
180
+ catch {
181
+ return;
182
+ }
183
+ for (const entry of entries) {
184
+ if (out.length >= MAX_FILES_PER_SKILL)
185
+ return;
186
+ if (entry.isSymbolicLink())
187
+ continue; // never follow — see doc comment above
188
+ const relPath = join(relDir, entry.name);
189
+ if (entry.isDirectory()) {
190
+ await walk(relPath, depth + 1);
191
+ }
192
+ else if (entry.isFile()) {
193
+ if (depth === 0 && entry.name === 'SKILL.md')
194
+ continue;
195
+ out.push({ filePath: relPath, fullPath: join(workspaceRoot, relPath) });
196
+ }
197
+ }
198
+ }
199
+ await walk(skillDirPath, 0);
200
+ return out;
201
+ }
202
+ /**
203
+ * Read, hash, and — when recognized and within the byte cap — content-scan
204
+ * one bundled script file discovered by `discoverSkillBundledFiles`.
205
+ *
206
+ * The sha256 hash is ALWAYS computed, even for a file this scanner cannot
207
+ * itself interpret (a binary, an unrecognized extension with no useful
208
+ * shebang) — that is deliberate, not a leftover from a simpler version of
209
+ * this function. A later, separate, opt-in phase (Cisco `skill-scanner` /
210
+ * VirusTotal-style hash lookup, Phase S3) needs the hash of every bundled
211
+ * file regardless of whether THIS scanner's regex patterns can say anything
212
+ * about its content.
213
+ *
214
+ * Refusal-not-pass, same contract as `auditSkillFile`: a read failure, a
215
+ * file over `MAX_SCRIPT_SCAN_BYTES`, or an unrecognized language all return
216
+ * `scanned: false` — never folded into `issuesDetected: 0` implying safety.
217
+ * A file over the byte cap is a REFUSAL to look, reported as such, not a
218
+ * silent skip and not a false "clean".
219
+ */
220
+ export async function auditScriptFile(filePath, fullPath, quiet = false) {
221
+ let buffer;
222
+ try {
223
+ buffer = await fs.readFile(fullPath);
224
+ }
225
+ catch (err) {
226
+ if (!quiet) {
227
+ log.warn(`[${filePath}] Could not be read (${err instanceof Error ? err.message : String(err)}) — reported as unscanned, not clean.`);
228
+ }
229
+ return { filePath, linesCount: 0, issuesDetected: 0, scanned: false, kind: 'script' };
230
+ }
231
+ const sha256 = createHash('sha256').update(buffer).digest('hex');
232
+ const firstLine = buffer.toString('utf8', 0, Math.min(buffer.length, 200)).split('\n')[0];
233
+ const language = detectScriptLanguage(filePath, firstLine);
234
+ if (buffer.length > MAX_SCRIPT_SCAN_BYTES) {
235
+ if (!quiet) {
236
+ log.warn(`[${filePath}] Exceeds the ${MAX_SCRIPT_SCAN_BYTES}-byte script scan cap — refusing to scan (hash recorded).`);
237
+ }
238
+ return { filePath, linesCount: 0, issuesDetected: 0, sha256, scanned: false, kind: 'script', language };
239
+ }
240
+ if (language === 'unknown') {
241
+ // Not a script language this scanner understands (binary, data file,
242
+ // unrecognized extension with no useful shebang) — hash-only, same
243
+ // refusal-not-pass status as an over-cap file.
244
+ return { filePath, linesCount: 0, issuesDetected: 0, sha256, scanned: false, kind: 'script', language };
245
+ }
246
+ const content = buffer.toString('utf8');
247
+ const contentLines = content.split('\n');
248
+ const result = scanScriptContent(content, language);
249
+ if (!result.clean && !quiet) {
250
+ for (const finding of result.findings) {
251
+ log.warn(`[${filePath}] ${finding.category} finding (${finding.patternId})${finding.excerpt ? `: ${finding.excerpt}` : ''}`);
252
+ }
253
+ }
254
+ return {
255
+ filePath,
256
+ linesCount: contentLines.length,
257
+ issuesDetected: result.findings.length,
258
+ findings: result.findings,
259
+ sha256,
260
+ scanned: true,
261
+ kind: 'script',
262
+ language,
263
+ };
264
+ }
15
265
  // ─── Skill Commands ──────────────────────────────────────────────────
16
266
  export async function runSkillList() {
17
267
  log.header('Intutic — Skill Discovery');
@@ -40,6 +290,23 @@ export async function runSkillList() {
40
290
  });
41
291
  }
42
292
  }
293
+ // Real skill directories (`.agents/skills`, `.claude/skills`) — discovery
294
+ // only here, matching the existing "list only counts" contract for this
295
+ // command; content scanning happens in `skill audit` below.
296
+ const skillFiles = await discoverSkillFiles(workspaceRoot);
297
+ for (const { filePath } of skillFiles) {
298
+ found++;
299
+ let lines = 0;
300
+ try {
301
+ lines = (await fs.readFile(join(workspaceRoot, filePath), 'utf8')).split('\n').length;
302
+ }
303
+ catch {
304
+ // Discovery already found the directory entry; a read failure here
305
+ // just means we can't report a line count.
306
+ }
307
+ console.log(` ${pc.green('✔')} ${pc.bold(filePath)} (${lines} lines)`);
308
+ skillsReport.push({ filePath, linesCount: lines, issuesDetected: 0 });
309
+ }
43
310
  if (found === 0) {
44
311
  log.info('No active skill or rule files found in workspace root.');
45
312
  }
@@ -54,28 +321,252 @@ export async function runSkillList() {
54
321
  await client.post(`/api/v1/workspaces/${creds.workspaceId}/skills/report`, { skills: skillsReport });
55
322
  log.dim('Reported local skills to Intutic control plane.');
56
323
  }
57
- catch (err) {
58
- // Non-blocking
324
+ catch {
325
+ // Non-blocking: discovery already printed everything the user asked for,
326
+ // and the report is a convenience for the dashboard.
327
+ }
328
+ }
329
+ }
330
+ /**
331
+ * A minimal, valid SARIF 2.1.0 document for CI/code-scanning interop.
332
+ *
333
+ * Built for GitHub Code Scanning and any other consumer of the format — not
334
+ * for Cisco's open-source `skill-scanner` project specifically, but so that
335
+ * tool's findings and ours can land in the same code-scanning pane on a repo
336
+ * that runs both. We do not shell out to or depend on that project; this is
337
+ * only a compatible OUTPUT shape.
338
+ *
339
+ * `rules` lists every pattern `scanSkillContent` knows how to report —
340
+ * SARIF's convention is the tool's full rule catalog, not only the ids that
341
+ * fired in this run, so a code-scanning UI can show a rule as "0 findings"
342
+ * rather than "unknown rule". `results` is built only from real skill files
343
+ * (`SkillReportEntry.findings`, populated by `auditSkillFile`); the legacy
344
+ * `.cursorrules`/`CLAUDE.md` regex checks above have no pattern ids and stay
345
+ * out of SARIF for that reason — see the entries' own `findings` being
346
+ * absent, not an omission here.
347
+ *
348
+ * `locations` deliberately carries no `region`: `SkillScanFinding` bounds an
349
+ * excerpt (see `skillScan.ts`'s `excerptFor`) but not a line/column, and
350
+ * SARIF's `region` is optional — fabricating `startLine: 1` would claim a
351
+ * precision this scanner does not have.
352
+ *
353
+ * `additionalRuns` (Phase S3): zero or more extra SARIF `runs[]`-shaped
354
+ * objects appended AFTER this codebase's own run — the Cisco skill-scanner
355
+ * integration's combined run, when that engine ran. SARIF is explicitly
356
+ * designed to carry multiple runs from different tools in one document, so
357
+ * these are appended AS-IS, never translated or merged into this function's
358
+ * own `results`/`rules` — that translation only happens for the internal
359
+ * `SkillScanFinding` representation (`mergeCiscoFindings`), never for this
360
+ * output mode.
361
+ */
362
+ export function buildSarifLog(entries, additionalRuns = []) {
363
+ const results = entries.flatMap((entry) => (entry.findings ?? []).map((finding) => ({
364
+ ruleId: finding.patternId,
365
+ level: 'warning',
366
+ message: {
367
+ text: finding.excerpt
368
+ ? `${finding.category} finding: ${finding.excerpt}`
369
+ : `${finding.category} finding (${finding.patternId})`,
370
+ },
371
+ locations: [
372
+ {
373
+ physicalLocation: {
374
+ artifactLocation: { uri: entry.filePath },
375
+ },
376
+ },
377
+ ],
378
+ })));
379
+ return {
380
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
381
+ version: '2.1.0',
382
+ runs: [
383
+ {
384
+ tool: {
385
+ driver: {
386
+ name: 'intutic-skill-scan',
387
+ informationUri: 'https://intutic.ai',
388
+ // The rule catalog is the union of both pattern tables — SARIF's
389
+ // convention is one driver's full rule set, not just the ids
390
+ // that fired this run, so a code-scanning UI can show a rule as
391
+ // "0 findings" rather than "unknown rule". Bundled-script
392
+ // findings (Phase S2, `SCRIPT_SCAN_PATTERNS`) land in the same
393
+ // `intutic-skill-scan` driver as `SKILL.md` content findings —
394
+ // one driver name, not a second tool, since both are the same
395
+ // "scan agent-loaded content for these threat shapes" capability.
396
+ rules: [...SKILL_SCAN_PATTERNS, ...SCRIPT_SCAN_PATTERNS].map((pattern) => ({
397
+ id: pattern.id,
398
+ shortDescription: { text: pattern.description },
399
+ defaultConfiguration: { level: 'warning' },
400
+ properties: { category: pattern.category },
401
+ })),
402
+ },
403
+ },
404
+ // Present and empty, never omitted, when nothing was flagged — a
405
+ // missing `results` key reads to some SARIF consumers as "the tool
406
+ // did not run" rather than "the tool ran and found nothing".
407
+ results,
408
+ },
409
+ ...additionalRuns,
410
+ ],
411
+ };
412
+ }
413
+ /**
414
+ * Longest excerpt a Cisco finding's `message` is trimmed to before becoming
415
+ * a `SkillScanFinding.excerpt`. Matches the control plane's
416
+ * `SkillScanFindingSchema.excerpt` cap (`harnessConfig.ts`) — this codebase's
417
+ * own findings bound their excerpt to ~40 chars of surrounding context
418
+ * (`EXCERPT_RADIUS`, `skillScan.ts`), but a Cisco `message.text` is a full
419
+ * LLM-authored sentence, not a regex match window, so this cap is generous
420
+ * rather than matching that radius — it exists to reject an attempt to
421
+ * smuggle something much larger through this field, not to enforce a tight
422
+ * budget on a legitimate finding description.
423
+ */
424
+ const CISCO_EXCERPT_CAP = 2000;
425
+ /**
426
+ * Maps the real `skill-scanner` category taxonomy — the `ThreatCategory`
427
+ * enum in the installed 2.0.13 package's `skill_scanner/core/models.py`,
428
+ * carried on each SARIF result's `properties.category` (see
429
+ * `lib/ciscoScanner.ts`'s module doc comment for how it's extracted) — onto
430
+ * this codebase's own three-category taxonomy. `prompt_injection` covers
431
+ * deception/manipulation vectors (including ones aimed at a human or the
432
+ * discovery/trust mechanism itself, not just the model); `data_exfiltration`
433
+ * covers anything about data or credentials leaving; everything else
434
+ * (execution, abuse, supply-chain, or genuinely uncategorizable) falls back
435
+ * to `'malicious_code'`, the broadest of the three buckets.
436
+ */
437
+ const CISCO_CATEGORY_MAP = {
438
+ prompt_injection: 'prompt_injection',
439
+ social_engineering: 'prompt_injection',
440
+ harmful_content: 'prompt_injection',
441
+ skill_discovery_abuse: 'prompt_injection',
442
+ transitive_trust_abuse: 'prompt_injection',
443
+ unicode_steganography: 'prompt_injection',
444
+ data_exfiltration: 'data_exfiltration',
445
+ hardcoded_secrets: 'data_exfiltration',
446
+ command_injection: 'malicious_code',
447
+ unauthorized_tool_use: 'malicious_code',
448
+ obfuscation: 'malicious_code',
449
+ resource_abuse: 'malicious_code',
450
+ policy_violation: 'malicious_code',
451
+ malware: 'malicious_code',
452
+ autonomy_abuse: 'malicious_code',
453
+ tool_chaining_abuse: 'malicious_code',
454
+ supply_chain_attack: 'malicious_code',
455
+ };
456
+ /** See {@link CISCO_CATEGORY_MAP}. Exported for tests. */
457
+ export function mapCiscoCategory(category) {
458
+ if (!category)
459
+ return 'malicious_code';
460
+ return CISCO_CATEGORY_MAP[category] ?? 'malicious_code';
461
+ }
462
+ /**
463
+ * Merges one skill directory's Cisco findings into that skill's existing
464
+ * report entries — the `SKILL.md` row, or a bundled-script row when a
465
+ * finding's own location matches one of `candidateEntries` exactly. Falls
466
+ * back to `fallbackEntry` (always the skill's `SKILL.md` row) when no
467
+ * location match is found — this codebase does not assume `skill-scanner`
468
+ * always attributes a finding to a specific bundled file, so an
469
+ * unmatched/absent location is treated as "belongs to the skill as a
470
+ * whole," not an error.
471
+ *
472
+ * Each Cisco finding becomes a `SkillScanFinding` with `patternId: 'cisco.'
473
+ * + ruleId`, a category from {@link mapCiscoCategory} (mapped from the
474
+ * finding's own `category`, not its `ruleId`), an excerpt capped at
475
+ * {@link CISCO_EXCERPT_CAP}, and `engine: 'cisco-skill-scanner'` — this
476
+ * codebase's own findings stamp `engine: 'native'` at construction
477
+ * (`skillScan.ts`/`scriptScan.ts`), so every finding in a report entry now
478
+ * carries its provenance.
479
+ *
480
+ * Returns the number of findings merged, for the caller's running `issues`
481
+ * count.
482
+ */
483
+ export function mergeCiscoFindings(candidateEntries, fallbackEntry, ciscoResult, workspaceRoot) {
484
+ if (!ciscoResult.ok)
485
+ return 0;
486
+ for (const finding of ciscoResult.findings) {
487
+ const mapped = {
488
+ patternId: `cisco.${finding.ruleId}`,
489
+ category: mapCiscoCategory(finding.category),
490
+ excerpt: finding.message.slice(0, CISCO_EXCERPT_CAP) || undefined,
491
+ engine: 'cisco-skill-scanner',
492
+ };
493
+ let target;
494
+ if (finding.filePath) {
495
+ const relPath = finding.filePath.startsWith(workspaceRoot)
496
+ ? finding.filePath.slice(workspaceRoot.length).replace(/^[/\\]/, '')
497
+ : finding.filePath;
498
+ target = candidateEntries.find((e) => e.filePath === relPath);
59
499
  }
500
+ target ??= fallbackEntry;
501
+ target.findings = [...(target.findings ?? []), mapped];
502
+ target.issuesDetected += 1;
60
503
  }
504
+ return ciscoResult.findings.length;
61
505
  }
62
- export async function runSkillAudit() {
63
- log.header('Intutic — Skill Security Audit');
506
+ export async function runSkillAudit(opts = {}) {
507
+ const sarif = opts.sarif === true;
508
+ // SARIF's contract is a single JSON document on stdout — a CI tool piping
509
+ // this into a code-scanning upload must not see decorated progress text
510
+ // interleaved with it. Every narrative call below is gated on `!sarif`;
511
+ // findings are still collected and reported identically either way.
512
+ if (!sarif)
513
+ log.header('Intutic — Skill Security Audit');
64
514
  const config = loadConfig();
65
515
  const workspaceRoot = config?.workspaceRoot ?? process.cwd();
66
516
  const creds = await loadCredentials().catch(() => null);
67
517
  let enableLocalSkillAuditDelete = false;
68
- // Fetch workspace settings to see if auto-delete is enabled
518
+ let ciscoSkillScannerEnabled = false;
519
+ // Phase S5 (TD-357): gates whether `auditSkillFile` below attaches full
520
+ // `SKILL.md` content to its report entry. Off unless the workspace opted
521
+ // in — see `WorkspaceSettings.semanticSkillAnalysisEnabled`'s own doc
522
+ // comment for what turning it on transmits.
523
+ let semanticSkillAnalysisEnabled = false;
524
+ // Fetch workspace settings to see if auto-delete / the Cisco auto-run /
525
+ // semantic analysis are enabled
69
526
  if (creds) {
70
527
  try {
71
528
  const client = await getClient(config?.devMode);
72
529
  const syncConfig = await client.fetchConfig(creds.workspaceId);
73
530
  enableLocalSkillAuditDelete = syncConfig.settings?.enableLocalSkillAuditDelete ?? false;
531
+ ciscoSkillScannerEnabled = syncConfig.settings?.ciscoSkillScannerEnabled ?? false;
532
+ semanticSkillAnalysisEnabled = syncConfig.settings?.semanticSkillAnalysisEnabled ?? false;
74
533
  }
75
534
  catch {
76
535
  // fallback to false
77
536
  }
78
537
  }
538
+ // ── Cisco skill-scanner engine resolution (Phase S3) ──────────────────
539
+ //
540
+ // Native scanning always runs — it is this command's baseline and always
541
+ // available. The Cisco engine additionally runs when EITHER:
542
+ // - `--engine cisco` was explicitly passed: a LOUD failure (non-zero
543
+ // exit) if the binary is absent, since the user explicitly asked for
544
+ // this engine and a silent skip would look like a clean scan.
545
+ // - the `ciscoSkillScannerEnabled` workspace setting is on: a graceful,
546
+ // info-level skip if the binary is absent, since this is an opt-in
547
+ // AUTO-run path, not an explicit request.
548
+ if (opts.engine !== undefined && opts.engine !== 'native' && opts.engine !== 'cisco') {
549
+ log.error(`Unknown --engine value '${opts.engine}'. Expected 'native' or 'cisco'.`);
550
+ process.exit(1);
551
+ }
552
+ const requestedCisco = opts.engine === 'cisco';
553
+ let runCisco = false;
554
+ if (requestedCisco || ciscoSkillScannerEnabled) {
555
+ const available = await ciscoScannerOnPath();
556
+ if (available) {
557
+ runCisco = true;
558
+ }
559
+ else if (requestedCisco) {
560
+ log.error("Cisco skill-scanner engine requested (--engine cisco) but the 'skill-scanner' binary " +
561
+ 'is not on PATH. Install it with `pipx install cisco-ai-skill-scanner`.');
562
+ process.exit(1);
563
+ }
564
+ else if (!sarif) {
565
+ log.info("ciscoSkillScannerEnabled is on, but the 'skill-scanner' binary is not on PATH — " +
566
+ 'skipping the Cisco engine for this run (native scanning still ran).');
567
+ }
568
+ }
569
+ const ciscoRawSarifRuns = [];
79
570
  const filesToScan = [
80
571
  '.cursorrules',
81
572
  'CLAUDE.md',
@@ -89,31 +580,35 @@ export async function runSkillAudit() {
89
580
  if (existsSync(fullPath)) {
90
581
  const content = await fs.readFile(fullPath, 'utf8');
91
582
  let fileIssues = 0;
92
- let contentLines = content.split('\n');
583
+ const contentLines = content.split('\n');
93
584
  let fileUpdated = false;
94
585
  // 1. Audit for secrets (AWS, Intutic, generic keys)
95
586
  if (content.match(/vk_[a-zA-Z0-9]{30,}/)) {
96
- log.error(`[${file}] Hardcoded Intutic virtual key prefix detected.`);
587
+ if (!sarif)
588
+ log.error(`[${file}] Hardcoded Intutic virtual key prefix detected.`);
97
589
  fileIssues++;
98
590
  }
99
591
  if (content.match(/sk-live-[a-zA-Z0-9]{30,}/)) {
100
- log.error(`[${file}] Hardcoded API secrets detected.`);
592
+ if (!sarif)
593
+ log.error(`[${file}] Hardcoded API secrets detected.`);
101
594
  fileIssues++;
102
595
  }
103
596
  // 2. Audit for unsafe wildcard commands (e.g. rm -rf *, sh *)
104
- if (content.match(/rm\s+-rf\s+[\*\/]/)) {
105
- log.warn(`[${file}] Unsafe recursive delete wildcard patterns (rm -rf *) found.`);
597
+ if (content.match(/rm\s+-rf\s+[*/]/)) {
598
+ if (!sarif)
599
+ log.warn(`[${file}] Unsafe recursive delete wildcard patterns (rm -rf *) found.`);
106
600
  fileIssues++;
107
601
  }
108
602
  if (content.match(/curl\s+|wget\s+/)) {
109
- log.warn(`[${file}] Network retrieval commands (curl, wget) found inside rules instructions.`);
603
+ if (!sarif)
604
+ log.warn(`[${file}] Network retrieval commands (curl, wget) found inside rules instructions.`);
110
605
  fileIssues++;
111
606
  }
112
607
  // 3. Auto-delete/prune unsafe lines if setting is enabled
113
608
  if (fileIssues > 0 && enableLocalSkillAuditDelete) {
114
609
  const filteredLines = contentLines.filter((line) => {
115
610
  const isSecret = line.match(/vk_[a-zA-Z0-9]{30,}/) || line.match(/sk-live-[a-zA-Z0-9]{30,}/);
116
- const isUnsafeCmd = line.match(/rm\s+-rf\s+[\*\/]/) || line.match(/curl\s+|wget\s+/);
611
+ const isUnsafeCmd = line.match(/rm\s+-rf\s+[*/]/) || line.match(/curl\s+|wget\s+/);
117
612
  if (isSecret || isUnsafeCmd) {
118
613
  fileUpdated = true;
119
614
  return false;
@@ -122,7 +617,8 @@ export async function runSkillAudit() {
122
617
  });
123
618
  if (fileUpdated) {
124
619
  await fs.writeFile(fullPath, filteredLines.join('\n'), 'utf8');
125
- log.success(`[${file}] Auto-pruned unsafe lines/rules during security audit.`);
620
+ if (!sarif)
621
+ log.success(`[${file}] Auto-pruned unsafe lines/rules during security audit.`);
126
622
  }
127
623
  }
128
624
  issues += fileIssues;
@@ -133,7 +629,60 @@ export async function runSkillAudit() {
133
629
  });
134
630
  }
135
631
  }
136
- if (issues === 0) {
632
+ // ── Real skill directories (`.agents/skills`, `.claude/skills`) ──────
633
+ //
634
+ // Content-aware scan via `scanSkillContent` (`@intutic/shared-types`),
635
+ // report-only per that module's doc comment — a finding here is never
636
+ // treated as ground truth, only surfaced. `enableLocalSkillAuditDelete`
637
+ // gates pruning here exactly as it already gates the legacy rule-file
638
+ // pruning above: the same opt-in, now covering both audit paths.
639
+ const skillFiles = await discoverSkillFiles(workspaceRoot);
640
+ for (const { filePath, fullPath } of skillFiles) {
641
+ const entry = await auditSkillFile(filePath, fullPath, enableLocalSkillAuditDelete, sarif, semanticSkillAnalysisEnabled);
642
+ issues += entry.issuesDetected;
643
+ skillsReport.push(entry);
644
+ // ── Bundled scripts alongside this skill's SKILL.md (TD-356, Phase S2) ─
645
+ //
646
+ // `discoverSkillFiles` only ever found `SKILL.md`; a sibling `setup.sh`
647
+ // or `helper.py` was invisible to every consumer before this. Enumerate
648
+ // (bounded, symlink-skipping — see `discoverSkillBundledFiles`) and
649
+ // audit each one the same way: always hashed, content-scanned only when
650
+ // the language is recognized and the file is within the byte cap.
651
+ const skillDirPath = dirname(filePath);
652
+ const bundledFiles = await discoverSkillBundledFiles(workspaceRoot, skillDirPath);
653
+ const bundledEntries = [];
654
+ for (const script of bundledFiles) {
655
+ const scriptEntry = await auditScriptFile(script.filePath, script.fullPath, sarif);
656
+ issues += scriptEntry.issuesDetected;
657
+ skillsReport.push(scriptEntry);
658
+ bundledEntries.push(scriptEntry);
659
+ }
660
+ // ── Cisco skill-scanner engine (Phase S3, opt-in) ───────────────────
661
+ //
662
+ // One invocation per skill directory — see `lib/ciscoScanner.ts`'s
663
+ // module doc comment for why the real CLI cannot take more than one
664
+ // `--path` per invocation. Findings merge into this skill's existing
665
+ // entries (SKILL.md or a matching bundled-script row); the raw SARIF
666
+ // run is kept separately for verbatim passthrough below.
667
+ if (runCisco) {
668
+ const [ciscoResult] = await runCiscoScan([join(workspaceRoot, skillDirPath)]);
669
+ if (ciscoResult.ok) {
670
+ issues += mergeCiscoFindings([entry, ...bundledEntries], entry, ciscoResult, workspaceRoot);
671
+ ciscoRawSarifRuns.push(...ciscoResult.sarifRuns);
672
+ }
673
+ else if (!sarif) {
674
+ log.warn(`[cisco] ${skillDirPath}: ${ciscoResult.error}`);
675
+ }
676
+ }
677
+ }
678
+ if (sarif) {
679
+ // The whole point: one JSON document, nothing else, on stdout. Cisco's
680
+ // combined run (if the engine ran) appends as a second `runs[]` entry,
681
+ // verbatim — see `buildSarifLog`'s doc comment.
682
+ const combinedCiscoRun = combineCiscoSarifRuns(ciscoRawSarifRuns);
683
+ console.log(JSON.stringify(buildSarifLog(skillsReport, combinedCiscoRun ? [combinedCiscoRun] : []), null, 2));
684
+ }
685
+ else if (issues === 0) {
137
686
  log.success('Skill security audit passed. No credentials or critical safety risks detected.');
138
687
  }
139
688
  else {
@@ -150,14 +699,109 @@ export async function runSkillAudit() {
150
699
  }
151
700
  }
152
701
  }
702
+ /**
703
+ * Lists staged files under the skill surface (`.agents/skills/**`,
704
+ * `.claude/skills/**`), ADDED/COPIED/MODIFIED only — mirroring the secret
705
+ * pre-commit scan's `git diff --cached` scope in `lib/gitHooks.ts`, so both
706
+ * checks agree on what "staged" means. A deleted skill file has nothing to
707
+ * scan.
708
+ *
709
+ * `execFileSync`, matching this package's existing git-invocation
710
+ * convention (`config/keychain.ts`, `commands/install-daemon.ts`) rather
711
+ * than a shelled-out string — no quoting to get wrong.
712
+ */
713
+ function listStagedSkillSurfaceFiles(workspaceRoot) {
714
+ try {
715
+ const out = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACM', '--', '.agents/skills', '.claude/skills'], { cwd: workspaceRoot, encoding: 'utf8' });
716
+ return out.split('\n').map((l) => l.trim()).filter(Boolean);
717
+ }
718
+ catch {
719
+ // Not a git repo, no staged changes, or git not on PATH — nothing to scan.
720
+ return [];
721
+ }
722
+ }
723
+ /**
724
+ * Reads the STAGED content of a path via `git show :<path>` — the blob that
725
+ * will actually be committed, not whatever is currently on disk. A file
726
+ * edited again after `git add` would otherwise be scanned in a state that
727
+ * does not match what `git commit` is about to record.
728
+ */
729
+ function readStagedBlob(workspaceRoot, filePath) {
730
+ try {
731
+ return execFileSync('git', ['show', `:${filePath}`], { cwd: workspaceRoot, encoding: 'utf8' });
732
+ }
733
+ catch {
734
+ return null;
735
+ }
736
+ }
737
+ /**
738
+ * Warn-only skill-content scan over staged additions in the skill surface.
739
+ *
740
+ * TD-358. This is the CLI entry point `lib/gitHooks.ts`'s pre-commit hook
741
+ * shells out to (`intutic skill scan-staged`) — deliberately separate from
742
+ * `skill audit` above, which scans the whole workspace and is too slow and
743
+ * too broad for a commit-time check that must stay fast.
744
+ *
745
+ * Never refuses a commit and never throws: `scanSkillContent`'s
746
+ * false-positive rate against real, benign skill markdown is unmeasured (see
747
+ * `skillScan.ts`'s module doc comment), so this phase stages the signal as
748
+ * an advisory only. Contrast `lib/gitHooks.ts`'s secret scan, which DOES
749
+ * refuse — that check is high-precision, prefixed credential shapes with no
750
+ * known false positives; this one is not, and must not borrow that check's
751
+ * authority until it has earned it the same way.
752
+ */
753
+ export async function runSkillScanStaged() {
754
+ const config = loadConfig();
755
+ const workspaceRoot = config?.workspaceRoot ?? process.cwd();
756
+ const staged = listStagedSkillSurfaceFiles(workspaceRoot);
757
+ if (staged.length === 0)
758
+ return;
759
+ let flaggedFiles = 0;
760
+ for (const filePath of staged) {
761
+ const content = readStagedBlob(workspaceRoot, filePath);
762
+ if (content === null)
763
+ continue; // vanished between diff and show — nothing to scan
764
+ const result = scanSkillContent(content);
765
+ if (result.clean)
766
+ continue;
767
+ flaggedFiles++;
768
+ for (const finding of result.findings) {
769
+ log.warn(`[${filePath}] ${finding.category} finding (${finding.patternId})` +
770
+ `${finding.excerpt ? `: ${finding.excerpt}` : ''} — advisory only, not blocking this commit.`);
771
+ }
772
+ }
773
+ if (flaggedFiles > 0) {
774
+ log.warn(`Skill-content scan flagged ${flaggedFiles} staged file(s) under .agents/skills or ` +
775
+ '.claude/skills. This is advisory only (TD-358) — the commit proceeds. Run ' +
776
+ '`intutic skill audit` for the full report.');
777
+ }
778
+ // No exit code is ever set here — the caller (the pre-commit hook) always
779
+ // treats this check as successful, by construction.
780
+ }
153
781
  // ─── Loop Commands ───────────────────────────────────────────────────
154
- async function getClient(dev) {
782
+ // Exported so `commands/decision.ts` can share the exact same client
783
+ // resolution (creds, dev-mode precedence, base URL) rather than growing a
784
+ // second, easily-drifting copy of it.
785
+ export async function getClient(dev) {
155
786
  const creds = await loadCredentials();
156
787
  if (!creds) {
157
- log.error('Not authenticated. This command needs an Intutic control plane, which open core does not include. To run the proxy without one: `intutic start`.');
788
+ log.error(NOT_AUTHENTICATED);
158
789
  process.exit(1);
159
790
  }
160
- const devMode = dev || process.env.INTUTIC_DEV === '1';
791
+ // `config?.devMode` is load-bearing and was missing here.
792
+ //
793
+ // Every other command that talks to the control plane resolves dev mode as
794
+ // `flag || config.devMode || INTUTIC_DEV` (sops, policy, budget, connect, exec).
795
+ // This one read only the flag and the env var, so a workspace configured with
796
+ // `devMode: true` still had `intutic loop start` resolve to https://api.intutic.ai
797
+ // — the loop commands, which are the governance feature, were the one family that
798
+ // ignored the workspace's own setting and silently addressed production.
799
+ //
800
+ // It went unnoticed because tools/cli/dist was stale: the built CLI predated this
801
+ // code and still read the config, so the bug only appeared once a workspace build
802
+ // regenerated dist from source.
803
+ const config = loadConfig();
804
+ const devMode = dev || config?.devMode || process.env.INTUTIC_DEV === '1';
161
805
  const controlPlaneUrl = resolveControlPlaneUrl(devMode);
162
806
  return createApiClient(controlPlaneUrl, creds.apiKey);
163
807
  }
@@ -167,7 +811,7 @@ async function resolveLocalSops(sopsArg) {
167
811
  const config = loadConfig();
168
812
  const workspaceRoot = config?.workspaceRoot ?? process.cwd();
169
813
  const sopsDir = join(workspaceRoot, '.intutic', 'sops');
170
- let dirs = [];
814
+ let dirs;
171
815
  try {
172
816
  const entries = await fs.readdir(sopsDir, { withFileTypes: true });
173
817
  dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
@@ -220,14 +864,14 @@ export async function runLoopStart(opts) {
220
864
  log.success(`Loop run active: ${pc.bold(res.loop.loopRunId)}`);
221
865
  log.info(`Budget limit: $${res.loop.budgetLimitUsd} USD`);
222
866
  // Write loop env file locally
223
- const loopEnvPath = join(process.env.HOME || '', '.intutic', 'env', 'loop.env');
867
+ const loopEnvPath = join(getIntuticDir(), 'env', 'loop.env');
224
868
  await fs.mkdir(dirname(loopEnvPath), { recursive: true }).catch(() => { });
225
869
  await fs.writeFile(loopEnvPath, `INTUTIC_LOOP_RUN_ID=${res.loop.loopRunId}\n`);
226
870
  log.dim(`Wrote run context to ~/.intutic/env/loop.env`);
227
871
  }
228
872
  }
229
873
  catch (err) {
230
- log.error(`Failed to register loop: ${err.message}`);
874
+ log.error(`Failed to register loop: ${err instanceof Error ? err.message : String(err)}`);
231
875
  }
232
876
  }
233
877
  export async function runLoopComplete(loopRunId, opts) {
@@ -238,12 +882,12 @@ export async function runLoopComplete(loopRunId, opts) {
238
882
  if (res.ok) {
239
883
  log.success(`Loop run ${loopRunId} completed.`);
240
884
  // Clean env
241
- const loopEnvPath = join(process.env.HOME || '', '.intutic', 'env', 'loop.env');
885
+ const loopEnvPath = join(getIntuticDir(), 'env', 'loop.env');
242
886
  await fs.rm(loopEnvPath, { force: true });
243
887
  }
244
888
  }
245
889
  catch (err) {
246
- log.error(`Failed to complete loop: ${err.message}`);
890
+ log.error(`Failed to complete loop: ${err instanceof Error ? err.message : String(err)}`);
247
891
  }
248
892
  }
249
893
  export async function runLoopKill(loopRunId, opts) {
@@ -253,12 +897,12 @@ export async function runLoopKill(loopRunId, opts) {
253
897
  const res = await client.post(`/api/v1/loops/${loopRunId}/kill`);
254
898
  if (res.ok) {
255
899
  log.warn(`Loop run ${loopRunId} marked as KILLED.`);
256
- const loopEnvPath = join(process.env.HOME || '', '.intutic', 'env', 'loop.env');
900
+ const loopEnvPath = join(getIntuticDir(), 'env', 'loop.env');
257
901
  await fs.rm(loopEnvPath, { force: true });
258
902
  }
259
903
  }
260
904
  catch (err) {
261
- log.error(`Failed to kill loop: ${err.message}`);
905
+ log.error(`Failed to kill loop: ${err instanceof Error ? err.message : String(err)}`);
262
906
  }
263
907
  }
264
908
  export async function runLoopList(opts) {
@@ -270,13 +914,49 @@ export async function runLoopList(opts) {
270
914
  console.log(` ${pc.bold('Loop Run ID')} | ${pc.bold('Name')} | ${pc.bold('Status')} | ${pc.bold('Token Spend')} | ${pc.bold('Budget Limit')}`);
271
915
  console.log(' ' + '-'.repeat(85));
272
916
  for (const loop of res.loops) {
273
- const statusStr = loop.status === 'ACTIVE' ? pc.green(loop.status) : loop.status === 'COMPLETED' ? pc.cyan(loop.status) : pc.red(loop.status);
917
+ // PENDING_REVIEW is amber, not red: it needs a person, but nothing has
918
+ // gone wrong. Falling through to the red branch made a held run look
919
+ // killed, which is the opposite of "waiting for you".
920
+ const statusStr = loop.status === 'ACTIVE'
921
+ ? pc.green(loop.status)
922
+ : loop.status === 'COMPLETED'
923
+ ? pc.cyan(loop.status)
924
+ : loop.status === 'PENDING_REVIEW'
925
+ ? pc.yellow(loop.status)
926
+ : pc.red(loop.status);
274
927
  console.log(` ${loop.loopRunId.padEnd(21)} | ${loop.name.padEnd(14)} | ${statusStr.padEnd(17)} | $${parseFloat(loop.totalTokenCostUsd).toFixed(4).padEnd(10)} | $${parseFloat(loop.budgetLimitUsd).toFixed(2)}`);
275
928
  }
276
929
  }
277
930
  }
278
931
  catch (err) {
279
- log.error(`Failed to list loops: ${err.message}`);
932
+ log.error(`Failed to list loops: ${err instanceof Error ? err.message : String(err)}`);
933
+ }
934
+ }
935
+ /**
936
+ * Approve or reject a run held for human review.
937
+ *
938
+ * The other half of `review_before:`. Without it a hold has no resolution from
939
+ * the terminal, and the hold is deliberately permanent — there is no reaper, so
940
+ * an unreviewed run stays blocked until a person acts.
941
+ */
942
+ export async function runLoopReview(loopRunId, opts) {
943
+ if (opts.approve === opts.reject) {
944
+ log.error('Specify exactly one of --approve or --reject.');
945
+ process.exit(1);
946
+ }
947
+ const action = opts.approve ? 'approve' : 'reject';
948
+ const client = await getClient(opts.dev);
949
+ try {
950
+ const res = await client.post(`/api/v1/loops/${loopRunId}/review`, { action, note: opts.note });
951
+ if (res.ok) {
952
+ log.success(action === 'approve'
953
+ ? `Loop run ${loopRunId} approved — it is ACTIVE again.`
954
+ : `Loop run ${loopRunId} rejected — it is KILLED.`);
955
+ }
956
+ }
957
+ catch (err) {
958
+ log.error(`Failed to resolve review: ${err instanceof Error ? err.message : String(err)}`);
959
+ process.exit(1);
280
960
  }
281
961
  }
282
962
  export async function runLoopExec(commandAndArgs, opts) {
@@ -325,25 +1005,50 @@ export async function runLoopExec(commandAndArgs, opts) {
325
1005
  shell: true,
326
1006
  });
327
1007
  child.on('close', async (code) => {
1008
+ // A non-zero exit is the agent failing, not governance stopping it. This used
1009
+ // to POST /kill for that case, which conflated the two: KILLED means the
1010
+ // circuit breaker or an operator intervened, and a run marked KILLED because
1011
+ // the wrapped process returned 1 makes budget-breach data unreadable. It also
1012
+ // meant the outcome was never recorded anywhere — the exit code was known
1013
+ // right here and thrown away, leaving no way to tell a successful trajectory
1014
+ // from a failed one afterwards.
1015
+ // A held run is not finished, it is waiting. Completing it would close the
1016
+ // review by side effect, with nothing recorded about who decided what — and
1017
+ // the route refuses it anyway (400). The agent exits non-zero when the proxy
1018
+ // returns 403, so without this check every hold ends in a confusing error.
1019
+ try {
1020
+ const current = await client.get(`/api/v1/loops/${loopRunId}`);
1021
+ if (current?.loop?.status === 'PENDING_REVIEW') {
1022
+ log.warn('This loop run is held for human review — it has not finished.');
1023
+ log.info(`Approve or reject it: intutic loop review ${loopRunId} --approve`);
1024
+ // loop.env is deliberately left in place: the resumed agent needs the
1025
+ // run id, and removing it here would orphan the run.
1026
+ process.exit(code || 0);
1027
+ return;
1028
+ }
1029
+ }
1030
+ catch {
1031
+ // Status unknown — fall through and record the outcome as usual rather
1032
+ // than leaving the run open on a transient error.
1033
+ }
328
1034
  if (code === 0) {
329
- await client.post(`/api/v1/loops/${loopRunId}/complete`).catch(() => { });
330
- log.success(`Loop command execution succeeded. Wrapper loop marked COMPLETED.`);
1035
+ await client
1036
+ .post(`/api/v1/loops/${loopRunId}/complete`, { outcome: 'SUCCEEDED' })
1037
+ .catch(() => { });
1038
+ log.success('Loop command execution succeeded. Wrapper loop marked COMPLETED.');
331
1039
  }
332
1040
  else {
333
- await client.post(`/api/v1/loops/${loopRunId}/kill`).catch(() => { });
334
- log.warn(`Loop command execution exited with code ${code}. Wrapper loop marked KILLED.`);
1041
+ await client
1042
+ .post(`/api/v1/loops/${loopRunId}/complete`, { outcome: 'FAILED' })
1043
+ .catch(() => { });
1044
+ log.warn(`Loop command execution exited with code ${code}. Wrapper loop marked FAILED.`);
335
1045
  }
336
1046
  process.exit(code || 0);
337
1047
  });
338
1048
  }
339
1049
  catch (err) {
340
- log.error(`Loop execution wrapper failed: ${err.message}`);
1050
+ log.error(`Loop execution wrapper failed: ${err instanceof Error ? err.message : String(err)}`);
341
1051
  process.exit(1);
342
1052
  }
343
1053
  }
344
- function dirname(filePath) {
345
- const parts = filePath.split('/');
346
- parts.pop();
347
- return parts.join('/');
348
- }
349
1054
  //# sourceMappingURL=skill.js.map